昨天有提到,根據統計,圖片和 JavaScript 為整體網頁檔案大小佔比的前兩名,兩者加起來佔了近九成。
認識 Next 針對圖片優化推出的 component toolkit - next/image
後,我們接著來看 JavaScript 載入的優化。
還沒讀過 Day 26 圖片優化的讀者,可以點擊文章傳送門
為了避免瀏覽器花很長時間下載目前頁面用不到的程式碼,使得網頁初始載入時間拖得很長,開發者可以將 JavaScript bundle 拆分成一小塊一小塊的 chunks,讓瀏覽器只載入目前所需要用到的部分,實現 lazy loading 的概念。
將 bundle 拆小的過程就叫做 code splitting。
( 圖片來源:https://nextjs.org/learn/foundations/how-nextjs-works/code-splitting )
要做 code splitting,可以使用 Webpack 搭配 CommonsChunkPlugin。這邊就不多做介紹,有興趣的讀者可參考官方文件或其他大大的文章:
Webpack - Code Splitting
莫大 - Day014 X Code Splitting & Dynamic Import
Next 預設會在 build-time 時,以路由為單位做 code splitting。
比方說,我們的 app 資料夾中的結構為:
├── dashboard
│ ├── page.tsx
│ └── settings
│ └── page.tsx
├── shop
│ └── page.tsx
├── layout.tsx
├── page.tsx
└── profile
└── page.tsx
run build 完,到 /.next/static/chunks/app
中可以看到會有 dashboard、profile、shop 三個資料夾,/dashboard
裡會有一個 settings 資料夾,資料夾裡各有一個 page-xxxx.js
檔案。
我們也可以從 terminal 查看各個 route segment 的檔案大小:
那假如我想單獨把某個 component 拆成一個 chunk 呢?
因為 Server Components 預設會做 code splitting,以下方法只適用 Client Components
假設想讓某個 component 獨立成一個 chunk,我們可以使用 Day 15 有提到的React.lazy()
。
舉個簡單範例:
假如頁面有個「打開彈窗」的按鈕,按下去會跳出彈窗,我希望讓彈窗 component <Modal>
打包時獨立成一個 chunk,按下「打開彈窗」後才去載入這個 chunk。就可以用 React.lazy
import <Modal>
:
'use client';
import React from 'react';
const Modal = React.lazy(
() => import('./components/Modal')
);
export default function Page() {
const [hasModal, setHasModal] = React.useState<boolean>(false);
return (
<div>
...
<button
onClick={() => setHasModal(true)}
>
打開彈窗
</button>
{hasModal && <Modal />}
</div>
);
}
完成後,我們打開 DevTools 的 Network,查看按鈕點擊後 JS 的 response:
可以發現,點擊「打開彈窗後」,會新載入一個 _app-pages-xxxxx
的 JS 檔,假如看 requested URL 可以發現的確是來自Modal.tsx
沒錯。
假如希望 chunk 檔名好識別一點,可以在 import 中加入 webpackChunkName
的註解:
const Modal = React.lazy(
() => import(/* webpackChunkName:"Modal" */ './components/Modal')
);
chunk 的名稱就會改為 Modal:
我們稍微來讓彈窗複雜一點點,在 <Modal>
中加入讀取 DB 用戶資料的邏輯。
這時 Modal.js
的下載時間就會比較久,這時我們可以使用 <Suspense>
,在 Modal.js
下載完之前,顯示 loading UI:
/* app/components/Loading.tsx */
export default function Loading() {
return (
<>
...
<div className='font-bold text-[30px]'>頁面載入中...</div>
...
</>
);
}
/* app/page.tsx */
'use client';
import React from 'react';
import Loading from './components/Loading';
const Modal = React.lazy(
() => import(/* webpackChunkName:"Modal" */ './components/Modal')
);
export default function Page() {
const [hasModal, setHasModal] = React.useState<boolean>(false);
return (
<div className='...'>
...
<button
onClick={() => setHasModal(true)}
>
打開彈窗
</button>
{hasModal && (
<React.Suspense fallback={<Loading />}>
<Modal />
</React.Suspense>
)}
</div>
);
}
這樣 Modal.js
下載完前,畫面就會顯示「頁面載入中...」:
Next 整合了 React.lazy 和 Suspense,提供一個動態 import 的模組 - next/dynamic
。
以上述例子來說,我們可以動態匯入 <Modal>
:
'use client';
import dynamic from 'next/dynamic';
import React from 'react';
import Loading from './components/Loading';
// 一樣可以用 webpackCunkName 命名 chunk
const Modal = dynamic(
() => import(/* webpackChunkName:"Modal" */ './components/Modal')
);
export default function Page() {
const [hasModal, setHasModal] = React.useState<boolean>(false);
return (
<div className='...'>
<button
onClick={() => setHasModal(true)}
>
打開彈窗
</button>
{hasModal && <Modal />}
</div>
);
}
禁用 ssr
如同 Day 14 提到,Next 預設為 Pre-Rendering,假如希望 lazy loaded 的 Client Components 能在瀏覽器渲染,就可以帶入 ssr:false
:
const Modal = dynamic(
() => import(/* webpackChunkName:"Modal" */ './components/Modal'),
{ ssr: false }
);
加入 loading 特效
我們也可以在 import 的第二個參數中,加入 loading component,就不需要使用 <Suspense>
:
const Modal = dynamic(
() => import(/* webpackChunkName:"Modal" */ './components/Modal'),
{ ssr: false, loading: () => <Loading /> }
);
Named Import
假如要動態匯入非 default export 的 component,可以從 import()
模組 return 的 Promise 取得:
/* app/components/Modals.tsx */
export function WelcomeModal() {
return <h1>Welcome aboard!</h1>;
}
/* app/page.tsx */
const WelcomeModal = dynamic(() =>
import(/* webpackChunkName:"WelcomeModal" */ './components/Modals').then(
(mod) => mod.WelcomeModal
)
);
export default function Page() {
...
return (
...
<WelcomeModal />
...
);
}
Dynamic Import 在 App Router 和 Pages Router 都可以使用
最後我們來觀察一下,<Modal>
有沒有使用 lazy loading,頁面初始 JS bundle 的大小差異。
先來看沒有使用 lazy loading 的版本:
/* app/page.tsx */
'use client';
import React from 'react';
import Modal from './components/Modal';
export default function Page() {
const [isModalOpen, setIsModalOpen] = React.useState<boolean>(false);
return (
<div className='...'>
<h1 className='...'>Lazy Loading Demo</h1>
<button
onClick={() => setIsModalOpen(true)}
className='...'
>
打開彈窗
</button>
{isModalOpen && <Modal />}
</div>
);
}
首頁 (/
) 首次載入的 chunk 的大小為 156 KB
接著來看使用 lazy loading 的版本:
'use client';
import dynamic from 'next/dynamic';
import React from 'react';
// Modal 使用 dynamic import
const Modal = dynamic(
() => import(/* webpackChunkName:"Modal" */ './components/Modal')
);
export default function Page() {
const [isModalOpen, setIsModalOpen] = React.useState<boolean>(false);
return (
<div className='...'>
<h1 className='...'>Lazy Loading Demo</h1>
<button
onClick={() => setIsModalOpen(true)}
className='...'
>
打開彈窗
</button>
{isModalOpen && <Modal />}
</div>
);
}
首頁 (/
) 首次載入的 chunk 減少為 84.4 KB
假如要載入第三方 script,Next 有提供另個 component toolkit - next/script
,針對第三方 script 載入提供幾個優化:
舉例來說,我今天在 app/profile/layout.tsx
中透過 next/script
嵌入 Firebase SDK:
/* app/profile/layout.tsx */
import Script from 'next/script';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<div className='...'>
{children}
</div>
<Script src='https://www.gstatic.com/firebasejs/10.4.0/firebase-app.js' />
</>
);
}
可以發現,進入首頁時,瀏覽器沒有下載 firebase-app.js
,等進到 /profile 或 /profile/settings 才下載。
從 /profile 透過 <Link>
轉到 /profile/settings,firebase-app.js
也不會重新下載:
假如想做更細節的載入設定,比方說想在 hydration 前載入,或是在 web worker 中載入,可以使用 strategy
來 fine tune next/script
。篇幅考量,就不多做介紹,有興趣的讀者可以參考官方文件。
假如想進一步查看每個 chunk 中 packages 的佔比,Next 也有支援 Webpack Bundel Analyzer:
要怎麼使用呢?
next.config.js
的設定:
/** @type {import('next').NextConfig} */
const nextConfig = {
...
};
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer(nextConfig);
ANALYZE=true npm run build
,過程會跳出一個瀏覽器視窗,顯示每個 chunk 的 packages 資訊:以上是幾個在 Next 專案中可以優化圖片和 JavaScript 載入的方法,希望對大家有幫助。介紹完 lazy loading 後,下一步會帶大家認識 App Router 中的 chaching 機制。
今天就先到這邊,謝謝大家耐心的閱讀,我們明天見!